34 — 烟花 shader

Three.js Journey · Bruno Simon · 难度:简单

34 — 烟花 shader 课程海报

简介

在本课中,我们将创建每当用户点击屏幕时绽放的烟花:

lesson demo video preview

这里的技巧在于,我们将使用 particles 并在单个 vertex shader 中对它们进行动画。

看上去并不太复杂,对吧?记住,为了获得良好的性能,我们需要在 vertex shader 中完成这些,这意味着同一段代码将为所有 particles 执行。

此外,前面提到的所有动画步骤都是相互交织的,我们需要找到一种方法来处理并将这些不同的步骤组合在一起。

最后,作为奖励,我们还会实现一个天空背景。

准备工作

我们得到了一贯的简单设置,其中有几点需要注意:

基础 particles

让我们创建 particles 来取代那个立方体。

删除整个带有立方体的 Test 部分,并在 Animate 部分之前创建一个 Fireworks 部分:

/**
 * Fireworks
 */

接下来几分钟我们将看到黑屏。

由于我们需要在每次点击时创建一个新的烟花,最好为它设置一个函数。

创建一个 createFirework 函数并立即调用它:

const createFirework = () =>
{

}

createFirework()

我们会在进行过程中不断添加各种参数,例如位置、半径、particles 数量等。

接下来大部分代码都会放在 createFirework 内。

Geometry

我们将实例化一贯的 BufferGeometry,并用随机位置填充它,但首先,我们需要知道想要多少个 particles。

给 createFirework 函数添加一个 count 参数,并在调用该函数时将其设为 100:

const createFirework = (count) =>
{

}

createFirework(100)

别担心,很快我们就会添加更多的参数。

现在使用一个长度为 count * 3 的 Float32Array 创建 positionsArray,因为每个 vertex 需要 3 个值(xyz):

const createFirework = (count) =>
{
    // Geometry
    const positionsArray = new Float32Array(count * 3)
}

从 0 循环到 count,创建一个 i3,它是 i * 3,以便我们得到一个每次递增 3、也就是以 3 为步长的值,并用随机值填充 positionsArray 数组:

const createFirework = (count) =>
{
    // Geometry
    const positionsArray = new Float32Array(count * 3)

    for(let i = 0; i < count; i++)
    {
        const i3 = i * 3

        positionsArray[i3    ] = Math.random()
        positionsArray[i3 + 1] = Math.random()
        positionsArray[i3 + 2] = Math.random()
    }
}

我们快速地过一遍这部分,因为这个操作我们之前已经做过很多次了。不过简单来说:每一次循环对应一个 vertex。目前我们只是用随机位置填充数组,但之后我们会发送更具体的坐标。

为每个 Math.random() 减去 0.5,使取值为正和为负的概率一样大:

const createFirework = (count) =>
{
    // Geometry
    const positionsArray = new Float32Array(count * 3)

    for(let i = 0; i < count; i++)
    {
        const i3 = i * 3

        positionsArray[i3    ] = Math.random() - 0.5
        positionsArray[i3 + 1] = Math.random() - 0.5
        positionsArray[i3 + 2] = Math.random() - 0.5
    }
}

现在我们可以创建真正的 BufferGeometry 了:

const createFirework = (count) =>
{
    // ...

    const geometry = new THREE.BufferGeometry()
}

然后,使用 Float32BufferAttribute 为它添加一个 'position' attribute:

const createFirework = (count) =>
{
    // ...

    const geometry = new THREE.BufferGeometry()
    geometry.setAttribute('position', new THREE.Float32BufferAttribute(positionsArray, 3))
}

3 对应 itemSize,表示每个 vertex 有 3 个值(因为 xyz)。

Material

对于 material,我们先不急于求成,从一个 PointsMaterial 开始,之后再将其替换为 ShaderMaterial:

const createFirework = (count) =>
{
    // ...

    // Material
    const material = new THREE.PointsMaterial()
}

Points

我们终于可以创建 particles 了,我们将它命名为 firework(单数),并使用一个 Points 实例,然后用 add() 将其添加到 scene 中:

const createFirework = (count) =>
{
    // ...

    // Points
    const firework = new THREE.Points(geometry, material)
    scene.add(firework)
}

终于,我们在屏幕上看到了东西,而前方还有一段漫长而令人兴奋的路要走。

我们将根据一个 position 参数来改变 firework 的 position:

const createFirework = (count, position) =>
{
    // ...

    // Points
    const firework = new THREE.Points(geometry, material)
    firework.position.copy(position)
    scene.add(firework)
}

createFirework(100, new THREE.Vector3())

目前还不太有用,但一旦我们在每次点击时随机放置它们,它就会派上用场。

自定义 shader

在 src/ 中,创建一个 shaders/firework/ 文件夹。

在 src/shaders/firework/ 文件夹中,创建一个 vertex.glsl 文件,并添加定位 vertices 所需的最小程序:

void main()
{
    // Final position
    vec4 modelPosition = modelMatrix * vec4(position, 1.0);
    vec4 viewPosition = viewMatrix * modelPosition;
    gl_Position = projectionMatrix * viewPosition;
}

我们把 modelPosition 和 viewPosition 分开,因为稍后我们会需要 viewPosition。

由于我们处理的是 particles(官方名为 points),我们还需要设置 gl_PointSize。暂时将其设为 20.0,它应该对应 20 个 fragments:

void main()
{
    // ...
    
    // Final size
    gl_PointSize = 20.0;
}

仍在 src/shaders/firework/ 文件夹中,创建一个 fragment.glsl 文件,并添加为 fragments 着粉色所需的最小程序。哦,别忘了 tonemapping_fragment 和 colorspace_fragment 这两个 include:

void main()
{
    // Final color
    gl_FragColor = vec4(1.0, 0.0, 1.0, 1.0);
    #include <tonemapping_fragment>
    #include <colorspace_fragment>
}

使用 shader 文件

回到 script.js,将这两个 shader 分别导入为 fireworkVertexShader 和 fireworkFragmentShader:

import fireworkVertexShader from './shaders/firework/vertex.glsl'
import fireworkFragmentShader from './shaders/firework/fragment.glsl'

将 PointsMaterial 替换为 ShaderMaterial,并在 vertexShader 和 fragmentShader 属性上使用这两个 shader:

const createFirework = (count, position) =>
{
    // ...
    const material = new THREE.ShaderMaterial({
        vertexShader: fireworkVertexShader,
        fragmentShader: fireworkFragmentShader
    })
    // ...
}

尺寸

首先,让我们处理尺寸。我们将采用与之前课程类似的方法,但要更进一步,更恰当、更可靠地处理这件事。

透视

正如我们在之前的课程中所见,要为 particles 添加透视(让它们越远越小),我们需要将 gl_PointSize 乘以 1.0 / - viewPosition.z。

这是我们从 Three.js shader chunks 中借鉴的一个技巧,在这种情况下它会继续工作得很好:

void main()
{
    // ...
    gl_PointSize = 20.0;
    gl_PointSize *= 1.0 / - viewPosition.z;
}
lesson demo video preview

现在 particles 会随着距离变小了,但它们有点太小了。把初始的 gl_PointSize 设为 50.0:

void main()
{
    // ...
    gl_PointSize = 50.0;
    // ...
}

这时,你可能会注意到你的 particles 比我的更小。这是因为没有考虑 pixel ratio。

对于相似的屏幕表面,你有更多的 pixels,使得 particles 看起来更小,因为它们本应覆盖 50 个 pixels。这正是我们接下来要修复的。

Uniform

我们现在想用一个 uniform 来控制尺寸。使用一个 Uniform 实例向 ShaderMaterial 添加一个 uSize uniform,并将其设为 50:

const createFirework = (count, position) =>
{
    // ...

    const material = new THREE.ShaderMaterial({
        // ...
        uniforms:
        {
            uSize: new THREE.Uniform(50)
        }
    })

    // ...
}

回到 vertex shader,将 uSize uniform 作为 float 获取,并将其发送给 gl_PointSize,取代原来硬编码的 50.0:

uniform float uSize;

void main()
{
    // ...

    // Final size
    gl_PointSize = uSize;
    gl_PointSize *= 1.0 / - viewPosition.z;
}

现在给 createFirework 函数添加一个 size 参数,将其传给 uSize uniform,并在调用 createFirework 函数时将其设为 50:

const createFirework = (count, position, size) =>
{
    // ...

    const material = new THREE.ShaderMaterial({
        // ...
        uniforms:
        {
            uSize: new THREE.Uniform(size)
        }
    })

    // ...
}

createFirework(100, new THREE.Vector3(), 50)

我们得到了与之前相同的结果,但现在可以通过 createFirework 的参数来控制尺寸了。

由于 createFirework 的参数开始变得很多,请随意添加换行和注释:

createFirework(
    100,                 // Count
    new THREE.Vector3(), // Position
    50                   // Size
)

与 render 高度成比例

现在还有两个问题要解决。众所周知的一个是前面提到的 pixel ratio,但另一个不太为人所知的问题是与 render 高度相关的尺寸。

要测试这一点,请垂直调整窗口大小,并观察 particles 的表现。

它们保持同样的尺寸。你可能会想,这很正常。嗯,其实并不然。在 Three.js 中,camera 的 FOV 是垂直的,这意味着对于固定的 FOV,物体尺寸会相对于高度保持不变。

让我们做一个快速测试来理解这一点。

在 createFirework 函数之外,创建一个白色平面:

// Test
const test = new THREE.Mesh(
    new THREE.PlaneGeometry(),
    new THREE.MeshBasicMaterial()
)
scene.add(test)

现在垂直调整窗口大小,看看平面如何变小。

lesson demo video preview

平面会随着 render 高度调整大小,我们需要 particles 也做到同样的事情。

在之前的课程中,我们只处理了 pixel ratio。在本课中,我们要把事情做对。

删除测试平面。

首先,我们需要将 render 的分辨率发送给 shader。

使用一个 Uniform 实例创建 uResolution,并向它发送一个 Vector2,其值为 sizes.width 和 sizes.height:

const createFirework = (count, position, size) =>
{
    // ...

    const material = new THREE.ShaderMaterial({
        // ...
        uniforms:
        {
            // ...
            uResolution: new THREE.Uniform(new THREE.Vector2(sizes.width, sizes.height))
        }
    })

    // ...
}

在 resize 回调函数中,我们想更新那个 uniform,但我们遇到了一个问题。

我们无法访问 material,因为它是在 createFirework 函数中创建的。

我们不能简单地在函数外部创建它,因为我们需要一些 createFirework 的参数来正确设置它。

相反,我们要做的是在 sizes 中创建一个 Vector2:

const sizes = {
    width: window.innerWidth,
    height: window.innerHeight
}
sizes.resolution = new THREE.Vector2(sizes.width, sizes.height)

并将其发送给 uResolution uniform:

const createFirework = (count, position, size) =>
{
    // ...

    const material = new THREE.ShaderMaterial({
        // ...
        uniforms:
        {
            // ...
            uResolution: new THREE.Uniform(sizes.resolution)
        }
    })

    // ...
}

现在我们可以在 resize 回调函数中访问它,并且可以更新它:

window.addEventListener('resize', () =>
{
    // Update sizes
    sizes.width = window.innerWidth
    sizes.height = window.innerHeight
    sizes.resolution.set(sizes.width, sizes.height)

    // ...
})

同时拥有 width、height 和 resolution 可能听起来有些冗余,但很快它就会派上用场。

让我们在 vertex shader 中将 uResolution uniform 作为 vec2 获取:

uniform float uSize;
uniform vec2 uResolution;

void main()
{
    // ...
}

现在我们可以通过乘以 uResolution.y 在 gl_PointSize 中使用它:

void main()
{
    // ...

    // Final size
    gl_PointSize = uSize * uResolution.y;
    gl_PointSize *= 1.0 / - viewPosition.z;
}

现在 particles 太大了。

有些人可能还会注意到,particles 并不像截图中那么大。这是由于一个限制:在某些特定设备(尤其是 Apple 的 MacBook)上,particle 尺寸不能超过 64 pixels。由于我们马上就会把 particles 做小很多,所以这不是什么大问题,只要你能看到 particles 就可以继续。

让我们在调用 createFirework() 时用 0.5 代替 50 来减小尺寸:

createFirework(
    100,                 // Count
    new THREE.Vector3(), // Position
    0.5                  // Size
)

现在,particles 将保持与 render 高度成比例。

lesson demo video preview

我们快完成了。现在我们需要处理 pixel ratio,而我们已经将这一信息以 Math.min(window.devicePixelRatio, 2) 的形式在代码中的两个地方发送给了 renderer。

让我们简化一下,把它保存为 sizes 的一个属性。

更新初始的 sizes,并用相同的公式添加 pixelRatio 属性:

const sizes = {
    width: window.innerWidth,
    height: window.innerHeight,
    pixelRatio: Math.min(window.devicePixelRatio, 2)
}

在 resize 回调函数中更新它:

window.addEventListener('resize', () =>
{
    // Update sizes
    sizes.width = window.innerWidth
    sizes.height = window.innerHeight
    sizes.pixelRatio = Math.min(window.devicePixelRatio, 2)
    sizes.resolution.set(sizes.width, sizes.height)
    // ...
})

将它发送给两处 renderer.setPixelRatio(),即在 resize 回调函数中和实例化 renderer 之后:

window.addEventListener('resize', () =>
{
    // ...
    renderer.setPixelRatio(sizes.pixelRatio)
})

// ...

const renderer = new THREE.WebGLRenderer({
    // ...
})
renderer.setSize(sizes.width, sizes.height)
renderer.setPixelRatio(sizes.pixelRatio)

这是一次大量的重构,但我们现在已经准备好以一种非常简单的方式来解决这个问题。

在实例化和更新 sizes.resolution 时,将 sizes.width 和 sizes.height 乘以 sizes.pixelRatio:

sizes.resolution = new THREE.Vector2(sizes.width * sizes.pixelRatio, sizes.height * sizes.pixelRatio)

window.addEventListener('resize', () =>
{
    // ...
    sizes.resolution.set(sizes.width * sizes.pixelRatio, sizes.height * sizes.pixelRatio)
    // ...
})

就这样。当 pixel ratio 假设为 2 时,uResolution 会变成两倍大,使得 particles 在 fragment 尺寸上变成两倍大,从而在屏幕上得到相同的尺寸。

Texture

既然 particle 尺寸已经正确了,让我们用 texture 让它看起来更好一些。

与之前的课程一样,我在 static/particles/ 文件夹中放了一些 texture。这是来自 Kenney 的、遵循 CC0 许可的精选 particle texture。

我们将会把它们全部加载,但只使用其中一个。之后,我们会随机选择一个。

在 createFirework 函数之前,创建一个 textures 数组并加载所有 particles 到其中:

const textures = [
    textureLoader.load('./particles/1.png'),
    textureLoader.load('./particles/2.png'),
    textureLoader.load('./particles/3.png'),
    textureLoader.load('./particles/4.png'),
    textureLoader.load('./particles/5.png'),
    textureLoader.load('./particles/6.png'),
    textureLoader.load('./particles/7.png'),
    textureLoader.load('./particles/8.png'),
]

const createFirework = (count, position, size) =>
{
    // ...
}

给 createFirework 函数添加一个 texture 参数,并在调用 createFirework 时从 textures 数组中发送一个元素:

const createFirework = (count, position, size, texture) =>
{
    // ...
}

createFirework(
    100,                 // Count
    new THREE.Vector3(), // Position
    0.5,                 // Size
    textures[7]          // Texture
)

使用一个 Uniform 实例将该 texture 作为 uTexture uniform 发送:

const createFirework = (count, position, size, texture) =>
{
    // ...

    // Material
    const material = new THREE.ShaderMaterial({
        // ...
        uniforms:
        {
            // ...
            uTexture: new THREE.Uniform(texture)
        }
    })

    // ...
}

在 fragment shader 中,将 uTexture uniform 作为 sampler2D 获取:

uniform sampler2D uTexture;

void main()
{
    // ...
}

如果你还记得上一课的内容,对于 particles,我们访问 texture 坐标的方式是不一样的,我们使用 gl_PointCoord,如果你好奇可以在 gl_FragColor 上测试它:

void main()
{
    // ...
    gl_FragColor = vec4(gl_PointCoord, 1.0, 1.0);
    // ...
}

使用 texture() 结合 gl_PointCoord 从 uTexture 中提取出颜色作为 vec4 textureColor:

void main()
{
    vec4 textureColor = texture(uTexture, gl_PointCoord);

    // ...
}

并将其发送给 gl_FragColor:

uniform sampler2D uTexture;

void main()
{
    vec4 textureColor = texture(uTexture, gl_PointCoord);

    // Final color
    gl_FragColor = textureColor;
    // ...
}

我们还有几件事要修复。

首先,如果你使用了和我相同的 particle texture,你可能会注意到它是上下颠倒的。

在 createFirework 中,就在我们将 texture 发送给 material 之前,将其 flipY 设为 false:

const createFirework = (count, position, size, texture) =>
{
    // ...

    // Material
    texture.flipY = false
    const material = new THREE.ShaderMaterial({
        // ...
    })

    // ...
}

我们本可以用别的方式来做,但使用这个解决方案能确保发送给该函数的任何 texture 都会将其 flipY 设为 false。这样它就能正常工作了。

既然我们就在 material 附近,就把它的 transparent 属性设为 true,因为我们已经知道我们会使用透明度:

const createFirework = (count, position, size, texture) =>
{
    // ...
    const material = new THREE.ShaderMaterial({
        // ...
        transparent: true
    })

    // ...
}

回到 fragment.glsl,我们不需要 texture 的 4 个通道,因为它是灰度图像。相反,只获取 r 通道并将其保存为 float textureAlpha:

void main()
{
    float textureAlpha = texture(uTexture, gl_PointCoord).r;
    // ...
}

将它发送给 gl_FragColor 的 alpha 通道,其余保持为 1.0:

uniform sampler2D uTexture;

void main()
{
    float textureAlpha = texture(uTexture, gl_PointCoord).r;

    // Final color
    gl_FragColor = vec4(1.0, 1.0, 1.0, textureAlpha);
    // ...
}
lesson demo video preview

与带 texture 的 particles 一样,透明度会有些混乱,因为即使它们是透明的,它们仍会在 depth buffer 上遮挡后面的内容。

要解决这个问题,请在 material 上将 depthWrite 设为 false。然后,借此机会将 blending 设为 AdditiveBlending,让 particles 更有光的感觉:

const createFirework = (count, position, size, texture) =>
{
    // ...
    const material = new THREE.ShaderMaterial({
        // ...
        depthWrite: false,
        blending: THREE.AdditiveBlending
    })

    // ...
}
lesson demo video preview

随机尺寸

一如既往,完美的 particles 是不存在的,看起来会不真实。我们需要为它们的尺寸增加随机性。

在 geometry 部分,创建一个长度为 count 的 sizesArray 并用随机值填充它:

const createFirework = (count, position, size, texture) =>
{
    // ...
    const sizesArray = new Float32Array(count)

    for(let i = 0; i < count; i++)
    {
        // ...

        sizesArray[i] = Math.random()
    }

    // ...
}

确保使用 i 而不是 i3,因为我们想要每次递增 1、以 1 为步长。

现在将该数组作为一个 Float32BufferAttribute 发送给 'aSize' attribute,并确保将 item size 设为 1,因为每个 vertex 只有一个值:

const createFirework = (count, position, size, texture) =>
{
    // ...

    geometry.setAttribute('position', new THREE.Float32BufferAttribute(positionsArray, 3))
    geometry.setAttribute('aSize', new THREE.Float32BufferAttribute(sizesArray, 1))

    // ...
}

在 vertex.glsl 中,将 aSize attribute 作为 float 获取,并将 gl_PointSize 乘以它:

// ...

attribute float aSize;

void main()
{
    // ...

    // Final size
    gl_PointSize = uSize * uResolution.y * aSize;
    gl_PointSize *= 1.0 / - viewPosition.z;
}

球体分布

目前,particles 被放置在一个长方体体积上,这对烟花来说并不常见。

相反,我们想把它们定位在一个球体上,而 Three.js 有完美的类可以做到这一点,即 Spherical。

首先,我们想通过 createFirework 的参数来控制球体的半径。

添加一个 radius 参数,并在调用 createFirework() 时将其设为 1:

const createFirework = (count, position, size, texture, radius) =>
{
    // ...
}

createFirework(
    100,                 // Count
    new THREE.Vector3(), // Position
    0.5,                 // Size
    textures[7],         // Texture
    1                    // Radius
)

在 for 循环中,就在填充 positionsArray 之前,实例化一个 Spherical,并将值设为 radius、Math.random() * Math.PI 和 Math.random() * Math.PI * 2:

const createFirework = (count, position, size, texture, radius) =>
{
    // ...

    for(let i = 0; i < count; i++)
    {
        const i3 = i * 3

        const spherical = new THREE.Spherical(
            radius,
            Math.random() * Math.PI,
            Math.random() * Math.PI * 2
        )
        // ...
    }

    // ...
}

第二个参数是 “phi” 角,第三个参数是 “theta” 角。Phi 从 0 到 PI(半个圆),theta 从 0 到两倍的 PI(一个完整的圆):

我们现在想把球坐标转换为 3D 位置,可以通过从一个 Vector3 创建 position 并调用其 setFromSpherical() 方法来实现:

const createFirework = (count, position, size, texture, radius) =>
{
    // ...

    for(let i = 0; i < count; i++)
    {
        const i3 = i * 3

        const spherical = new THREE.Spherical(
            radius,
            Math.random() * Math.PI,
            Math.random() * Math.PI * 2
        )
        const position = new THREE.Vector3()
        position.setFromSpherical(spherical)

        // ...
    }

    // ...
}

最后,我们可以将 position 的属性赋值给 positionsArray:

const createFirework = (count, position, size, texture, radius) =>
{
    // ...

    for(let i = 0; i < count; i++)
    {
        // ...

        positionsArray[i3    ] = position.x
        positionsArray[i3 + 1] = position.y
        positionsArray[i3 + 2] = position.z

        // ...
    }

    // ...
}
lesson demo video preview

看起来像一个球体,但它太完美了。

要解决这个问题,我们将在实例化 Spherical 时对 radius 稍微随机化:

const createFirework = (count, position, size, texture, radius) =>
{
    // ...

    for(let i = 0; i < count; i++)
    {
        // ...

        const spherical = new THREE.Spherical(
            radius * (0.75 + Math.random() * 0.25),
            Math.random() * Math.PI,
            Math.random() * Math.PI * 2
        )

        // ...
    }

    // ...
}
lesson demo video preview

这个花哨的公式背后是一个简单的计算。我们将 radius 乘以一个在 0.75 到 1 之间变化的数。这样,particles 就不会超出设定的 radius,但可以离中心近至多 25%。

颜色

虽然最终颜色会是随机的,但所有 particles 的颜色都一样,我们想通过 createFirework 的参数来控制它。

给 createFirework 函数添加一个 color 参数,并在调用 createFirework() 时将其设为一个 Color 实例:

const createFirework = (count, position, size, texture, radius, color) =>
{
    // ...
}

createFirework(
    100,                       // Count
    new THREE.Vector3(),       // Position
    0.5,                       // Size
    textures[7],               // Texture
    1,                         // Radius
    new THREE.Color('#8affff') // Color
)

使用一个 Uniform 实例添加一个 uColor uniform,并将 color 参数发送给它:

const createFirework = (count, position, size, texture, radius, color) =>
{
    // ...

    const material = new THREE.ShaderMaterial({
        // ...
        uniforms:
        {
            // ...
            uColor: new THREE.Uniform(color)
        },
        // ...
    })

    // ...
}

在 fragment shader 中,将 uColor 作为 vec3 获取,并将其发送给 gl_FragColor 的前 3 个值:

// ...
uniform vec3 uColor;

void main()
{
    // ...
    gl_FragColor = vec4(uColor, textureAlpha);
    // ...
}

进度

我们终于进入了动画部分,但要控制这个动画,我们需要一个从 0 变化到 1 的值。然后,我们会在 vertex shader 中使用这个唯一的值来控制动画的所有方面。

使用一个 Uniform 实例创建一个 uProgress uniform,并将其设为 0:

const createFirework = (count, position, size, texture, radius, color) =>
{
    // ...

    const material = new THREE.ShaderMaterial({
        // ...
        uniforms:
        {
            // ...
            uProgress: new THREE.Uniform(0)
        }
    })

    // ...
}

为了将这个 uniform 从 0 动画到 1,我们将使用 GSAP,这有点小题大做,但我们已经熟悉 GSAP,而且它很方便。

从 'gsap' 导入 gsap,它已经在项目依赖中了:

import gsap from 'gsap'

在 createFirework 函数的末尾,调用 gsap.to() 将 material.uniforms.uProgress.value 动画到 1:

const createFirework = (count, position, size, texture, radius, color) =>
{
    // ...

    // Animate
    gsap.to(
        material.uniforms.uProgress,
        { value: 1 },
    )
}

虽然我们看不到它,因为它只是一个数字,但就目前而言,value 应该会正确地做动画。

默认情况下,GSAP 将持续时间设为 1,这有点太快了。使用 duration 属性将其改为 3:

const createFirework = (count, position, size, texture, radius, color) =>
{
    // ...

    // Animate
    gsap.to(
        material.uniforms.uProgress,
        { value: 1, duration: 3 },
    )
}

默认情况下,GSAP 使用非线性缓动,这样动画看起来不会太机械。我们只会把这个值当作参考,并在 vertex shader 中自行应用缓动,这就是为什么我们需要这个值是完美线性的。将 ease 属性设为'linear':

const createFirework = (count, position, size, texture, radius, color) =>
{
    // ...

    // Animate
    gsap.to(
        material.uniforms.uProgress,
        { value: 1, duration: 3, ease: 'linear' },
    )
}

Dispose

现在正是释放烟花的绝佳时机。

当动画结束时,烟花的 particles 不应该可见,我们可以恰当地将其销毁。

我们可以通过添加一个 onComplete 属性并向其发送一个回调函数,来获知 GSAP 动画结束。

在 gsap.to() 之前,创建一个 destroy 函数,在里面放一个 console.log('destroy'),并将其发送给 gsap.to() 的 onComplete 属性:

const createFirework = (count, position, size, texture, radius, color) =>
{
    // ...

    // Destroy
    const destroy = () =>
    {
        console.log('destroy')
    }

    // Animate
    gsap.to(
        material.uniforms.uProgress,
        { value: 1, duration: 3, ease: 'linear', onComplete: destroy },
    )
}
lesson demo video preview

3 秒后,你应该会在 Console 中看到 'destroy'。

现在的问题是,我们应该释放什么?

Three.js 文档包含大量关于此的信息 https://threejs.org/docs/#manual/en/introduction/How-to-dispose-of-objects

在我们的例子中,我们需要释放 geometry 和 material,但还需要从 scene 中移除 Points 实例:

const createFirework = (count, position, size, texture, radius, color) =>
{
    // ...

    // Destroy
    const destroy = () =>
    {
        scene.remove(firework)
        geometry.dispose()
        material.dispose()
    }

    // ...
}
lesson demo video preview

3 秒后,烟花消失了。

还要注意,我们不会释放 texture,因为当用户点击创建烟花时,我们可能会将它用于其他烟花。

点击时生成

现在调试会更困难,因为我们需要重新加载页面才能看到新的烟花。让我们为每次点击创建一个。

目前,我们将创建完全相同的烟花,但之后,我们会将其随机化。

在调用 createFirework() 之后,在 window 上监听 'click' 事件,并用相同的参数调用完全相同的函数:

createFirework(
    100,                       // Count
    new THREE.Vector3(),       // Position
    0.5,                       // Size
    textures[7],               // Texture
    1,                         // Radius
    new THREE.Color('#8affff') // Color
)

window.addEventListener('click', () =>
{
    createFirework(
        100,                       // Count
        new THREE.Vector3(),       // Position
        0.5,                       // Size
        textures[7],               // Texture
        1,                         // Radius
        new THREE.Color('#8affff') // Color
    )
})
lesson demo video preview

点击将生成一个新的实例,它们总是在 3 秒后消失,而且我们知道它们被恰当地释放了。

动画

所有的这些准备工作把我们带到了这一刻。是时候让 particles 动起来了。

正如开头提到的,动画由 5 个不同的阶段组成:

而这些阶段相互重叠,否则就太简单了(而且不好看)。

用单个进度值创建如此复杂动画的技巧在于,将它们恰当地分离,并对进度进行重映射以适配我们正在处理的阶段。

让我们从第一个阶段开始,让 particles 爆炸。

爆炸

首先,在 vertex shader 中,我们需要将 uProgress 作为 float 获取:

uniform float uProgress;

我们想改变 position 变量,但由于它是一个 attribute,我们无法更新它。

从 position 创建一个 newPosition,并将其发送给 modelPosition:

void main()
{
    vec3 newPosition = position;

    // Final position
    vec4 modelPosition = modelMatrix * vec4(newPosition, 1.0);
    // ...
}

我们现在想对 uProgress 进行重映射,使其在动画开始时从 0 到 1。

到目前为止,我们一直在使用 smoothstep,这是一个不错的解决方案,但在我们的例子中,我们只想要重映射部分,而不想要平滑部分:

遗憾的是,GLSL 中没有为此内置的函数,但我们可以使用下面这个函数,将它添加到 main() 之前:

float remap(float value, float originMin, float originMax, float destinationMin, float destinationMax)
{
    return destinationMin + (value - originMin) * (destinationMax - destinationMin) / (originMax - originMin);
}

void main()
{
    // ...
}

不深入细节的话,你需要传入:

我们希望爆炸发生在 0 到 0.1 之间。创建一个 explodingProgress 变量并按这种方式重映射它:

void main()
{
    // ...

    // Exploding
    float explodingProgress = remap(uProgress, 0.0, 0.1, 0.0, 1.0);

    // ...
}

我们现在要根据这个 explodingProgress 将 vertices 从中心移动到它们的正常位置,可以通过将 newPosition 乘以 explodingProgress 来实现:

void main()
{
    // ...

    // Exploding
    float explodingProgress = remap(uProgress, 0.0, 0.1, 0.0, 1.0);
    newPosition *= explodingProgress;

    // ...
}
lesson demo video preview

不错,但 particles 难道不应该在 0.1 处停下吗?

是的,应该如此,但我们的 remap 函数并不会阻止值超出或低于我们提供的界限。

要解决这个问题,我们可以在 remap 之后立即使用 clamp 来钳制该值:

void main()
{
    // ...

    // Exploding
    float explodingProgress = remap(uProgress, 0.0, 0.1, 0.0, 1.0);
    explodingProgress = clamp(explodingProgress, 0.0, 1.0);
    newPosition *= explodingProgress;

    // ...
}
lesson demo video preview

现在,我们希望动画在开始时很快,并在到达过渡终点之前慢下来。

在之前的课程中,我们已经看到可以使用 pow 来获得这种效果:

void main()
{
    // ...

    // Exploding
    float explodingProgress = remap(uProgress, 0.0, 0.1, 0.0, 1.0);
    explodingProgress = clamp(explodingProgress, 0.0, 1.0);
    explodingProgress = pow(explodingProgress, 3.0);
    newPosition *= explodingProgress;

    // ...
}
lesson demo video preview

这样做得到了与我们想要相反的效果。它开始得慢,在最后加速,如果你看看 3 次幂的曲线,这是合理的:

要解决这个问题,我们将反转该值,使其从 1 到 0 而不是从 0 到 1,应用幂,然后再反转回来:

void main()
{
    // ...

    // Exploding
    float explodingProgress = remap(uProgress, 0.0, 0.1, 0.0, 1.0);
    explodingProgress = clamp(explodingProgress, 0.0, 1.0);
    explodingProgress = 1.0 - pow(1.0 - explodingProgress, 3.0);
    newPosition = mix(vec3(0.0), newPosition, explodingProgress);

    // ...
}
lesson demo video preview

再次注意,这之所以有效,仅仅是因为我们的值范围从 0 到 1。

下落

紧接着那次爆炸之后,我们希望 particles 下落:

创建一个 fallingProgress,并应用完全相同的技术,只是 remap() 的参数不同:

void main()
{
    // ...

    // Falling
    float fallingProgress = remap(uProgress, 0.1, 1.0, 0.0, 1.0);
    fallingProgress = clamp(fallingProgress, 0.0, 1.0);
    fallingProgress = 1.0 - pow(1.0 - fallingProgress, 3.0);

    // ...
}

我们可以用 fallingProgress 从 newPosition.y 中减去,并乘以 0.2:

void main()
{
    // ...

    // Falling
    float fallingProgress = remap(uProgress, 0.1, 1.0, 0.0, 1.0);
    fallingProgress = clamp(fallingProgress, 0.0, 1.0);
    fallingProgress = 1.0 - pow(1.0 - fallingProgress, 3.0);
    newPosition.y -= fallingProgress * 0.2;

    // ...
}
lesson demo video preview

缩放

缩放部分稍微棘手一些,因为 particle 在开始时快速放大,然后在末尾缓慢缩小。

我们将分别计算放大和缩小,取两者中较小的一个,并对其进行钳制。

使用通常的 remap() 计算 sizeOpeningProgress:

void main()
{
    // ...

    // Scaling
    float sizeOpeningProgress = remap(uProgress, 0.0, 0.125, 0.0, 1.0);

    // ...
}

使用 remap() 计算 sizeClosingProgress,但反转最后两个值,使其从 1 到 0 而不是从 0 到 1:

void main()
{
    // ...

    // Scaling
    float sizeOpeningProgress = remap(uProgress, 0.0, 0.125, 0.0, 1.0);
    float sizeClosingProgress = remap(uProgress, 0.125, 1.0, 1.0, 0.0);

    // ...
}

使用 min 取得两者中的最小值,并将其保存为 sizeProgress:

void main()
{
    // ...

    // Scaling
    float sizeOpeningProgress = remap(uProgress, 0.0, 0.125, 0.0, 1.0);
    float sizeClosingProgress = remap(uProgress, 0.125, 1.0, 1.0, 0.0);
    float sizeProgress = min(sizeOpeningProgress, sizeClosingProgress);

    // ...
}

使用 clamp 将 sizeProgress 钳制在 0 到 1 之间:

void main()
{
    // ...

    // Scaling
    float sizeOpeningProgress = remap(uProgress, 0.0, 0.125, 0.0, 1.0);
    float sizeClosingProgress = remap(uProgress, 0.125, 1.0, 1.0, 0.0);
    float sizeProgress = min(sizeOpeningProgress, sizeClosingProgress);
    sizeProgress = clamp(sizeProgress, 0.0, 1.0);

    // ...
}

将 sizeProgress 与其余部分相乘,用在 gl_PointSize 上:

void main()
{
    // ...

    gl_PointSize = uSize * uResolution.y * aSize * sizeProgress;
    // ...
}
lesson demo video preview

闪烁

对于最后一部分,我们希望 particles 闪烁,也就是说它们会相当快速地放大和缩小,仿佛燃烧它们的火焰正在失控地消退。

我们希望在 particles 开始缩小之后不久开始闪烁,但效果要足够快地积累起来,这样我们才能在 particle 消失之前欣赏到它:

首先,像之前的部分一样计算 twinklingProgress,使用一个从 0.2 到 0.8 的 remap 和一个 clamp:

void main()
{
    // ...

    // Twinkling
    float twinklingProgress = remap(uProgress, 0.2, 0.8, 0.0, 1.0);
    twinklingProgress = clamp(twinklingProgress, 0.0, 1.0);

    // Final position
    // ...
}

让我们先把 twinklingProgress 放在一边,试着让 particles 一直闪烁。

由于缩放需要反复放大和缩小,我们可以在 uProgress 上使用经典的 sin。

创建一个 float sizeTwinkling 变量,并为它赋值 uProgress 的 sin():

void main()
{
    // ...

    // Twinkling
    float twinklingProgress = remap(uProgress, 0.2, 0.8, 0.0, 1.0);
    twinklingProgress = clamp(twinklingProgress, 0.0, 1.0);
    float sizeTwinkling = sin(uProgress);

    // Final position
    // ...
}

在继续之前,我们知道 sine 从 -1 到 +1,而我们想要一个从 0 到 1 的值。将 sizeTwinkling 乘以 0.5 并加上 0.5:

void main()
{
    // ...
    float sizeTwinkling = sin(uProgress) * 0.5 + 0.5;

    // ...
}

我们还知道 uProgress 从 0 到 1,这不会在 sin() 中产生很多波。将 uProgress 乘以 30.0:

void main()
{
    // ...
    float sizeTwinkling = sin(uProgress * 30.0) * 0.5 + 0.5;

    // ...
}

我们现在可以将 sizeTwinkling 发送给 gl_PointSize:

void main()
{
    // ...
    gl_PointSize = uSize * uResolution.y * aSize * sizeProgress * sizeTwinkling;
    // ...
}
lesson demo video preview

最后,我们需要将 twinklingProgress 与 sizeTwinkling 结合起来:

void main()
{
    // ...
    float sizeTwinkling = sin(uProgress * 30.0) * 0.5 + 0.5;
    sizeTwinkling = sizeTwinkling * twinklingProgress;

    // ...
}
lesson demo video preview

这样做是错误的,因为当 twinklingProgress 为 0 时,sizeTwinkling 最终也会是 0,导致 particles 除非在闪烁否则不可见。

要解决这个问题,我们可以从 1.0 开始,然后减去我们计算的值,从而反转该值:

void main()
{
    // ...
    float sizeTwinkling = sin(uProgress * 30.0) * 0.5 + 0.5;
    sizeTwinkling = 1.0 - sizeTwinkling * twinklingProgress;

    // ...
}
lesson demo video preview

随机化时间

这一切看起来很棒,但同样,太规律了。所有 particles 都在完全相同的时间遵循相似的动画,这在闪烁方面尤其令人不适。

它们应该同时开始,但它们的生命周期应该不同。不过,由于我们在动画结束时释放烟花,所以没有哪个 particle 应该耗时更长。此外,我们需要它们在释放之前就消失。

我们将创建一个作为 attribute 的时间乘数,让 particles 随机地更快一些。

在 geometry 部分,创建一个长度为 count 的 timeMultipliersArray,并用从 1 到 2 随机变化的值填充它:

const createFirework = (count, position, size, texture, radius, color) =>
{
    // ...
    const timeMultipliersArray = new Float32Array(count)

    for(let i = 0; i < count; i++)
    {
        // ...

        timeMultipliersArray[i] = 1 + Math.random()
    }

    // ...
}

确保使用 i 而不是 i3,因为我们想要每次递增 1、以 1 为步长。

现在使用 Float32BufferAttribute 创建 'aTimeMultiplier' attribute,并确保将 item size 设为 1,因为每个 vertex 只有一个值:

const createFirework = (count, position, size, texture, radius, color) =>
{
    // ...

    geometry.setAttribute('position', new THREE.Float32BufferAttribute(positionsArray, 3))
    geometry.setAttribute('aSize', new THREE.Float32BufferAttribute(sizesArray, 1))
    geometry.setAttribute('aTimeMultiplier', new THREE.Float32BufferAttribute(timeMultipliersArray, 1))

    // ...
}

在 vertex shader 中,将 aTimeMultiplier attribute 作为 float 获取:

// ...

attribute float aSize;
attribute float aTimeMultiplier;

void main()
{
    // ...
}

在 main() 的开头,创建一个 float progress 并将 uProgress 乘以 aTimeMultiplier 赋值给它,然后将其用于各个进度计算,取代 uProgress:

void main()
{
    float progress = uProgress * aTimeMultiplier;

    // ...

    float explodingProgress = remap(progress, 0.0, 0.1, 0.0, 1.0);
    
    // ...

    float fallingProgress = remap(progress, 0.1, 1.0, 0.0, 1.0);

    // ...

    float sizeOpeningProgress = remap(progress, 0.0, 0.125, 0.0, 1.0);
    float sizeClosingProgress = remap(progress, 0.125, 1.0, 1.0, 0.0);

    // ...

    float twinklingProgress = remap(progress, 0.2, 0.8, 0.0, 1.0);
    // ...
    float sizeTwinkling = sin(progress * 40.0) * 0.5 + 0.5;
}
lesson demo video preview

现在 particles 以不同的速度推进,闪烁看起来更随机了。

Windows 问题

有些人可能已经注意到,即使 particles 极其微小,它们在完全从 scene 中移除之前仍然可见。这种行为因你的 OS 和 GPU 而异,但大多数情况下,它影响 Windows 用户,我们无论如何都需要修复它。

发生的事情是,对于这些用户,gl_PointSize 被钳制到最小值 1.0。任何低于 1.0 的值最终都会变成 1.0,从而阻止 particles 消失。

我们要做的是,当 gl_PointSize 低于 1.0 时完全隐藏 particle。

遗憾的是,没有 GLSL 指令可以做到这一点。相反,我们将使用一个笨拙的技巧,即将 particles 移动到远离渲染裁剪空间的位置。

在 vertex.glsl 中,使用一个 if 语句来测试 gl_PointSize 是否低于 1.0,如果是,则将 gl_Position 设为 vec4(9999.9):

void main()
{
    // ...
    
    // Final size
    // ...

    if(gl_PointSize < 1.0)
        gl_Position = vec4(9999.9);
}

随机性

我们的动画就完成了。

是时候给这些烟花增加一些随机性了。

在 createFirework 函数之后创建一个 createRandomFirework 函数:

const createFirework = (count, position, size, texture, radius, color) =>
{
    // ...
}

const createRandomFirework = () =>
{
    
}

在其中,创建一个随机的 count:

const createRandomFirework = () =>
{
    const count = Math.round(400 + Math.random() * 1000)
}

创建一个随机的 position:

const createRandomFirework = () =>
{
    // ...
    const position = new THREE.Vector3(
        (Math.random() - 0.5) * 2,
        Math.random(),
        (Math.random() - 0.5) * 2
    )
}

创建一个随机的 size:

const createRandomFirework = () =>
{
    // ...
    const size = 0.1 + Math.random() * 0.1
}

在 textures 中随机选择一个 texture:

const createRandomFirework = () =>
{
    // ...
    const texture = textures[Math.floor(Math.random() * textures.length)]
}

创建一个随机的 radius:

const createRandomFirework = () =>
{
    // ...
    const radius = 0.5 + Math.random()
}

使用 Color 类及其 setHSL() 方法创建一个随机的 color:

const createRandomFirework = () =>
{
    // ...
    const color = new THREE.Color()
    color.setHSL(Math.random(), 1, 0.7)
}

使用所有这些随机变量调用 createFirework():

const createRandomFirework = () =>
{
    // ...
    createFirework(count, position, size, texture, radius, color)
}

将最初的 createFirework() 调用替换为 createRandomFirework(),并将其作为 'click' 监听器的回调函数发送:

const createRandomFirework = () =>
{
    // ...
}

createRandomFirework()

document.addEventListener('click', createRandomFirework)
lesson demo video preview

开火!

天空背景

我们的烟花完成了,但让我们再多玩一会儿,添加一个漂亮的天空背景。

为此,我们将使用 Sky 类。

这是一个基于物理的类,会产生逼真的天空,你可以模拟日落、黎明、明亮的白天等。

遗憾的是,关于这个类的文档不多,但其 示例 是不言自明的,而且很容易实现。

打开这个 示例,然后点击页面右下角的 <> 图标打开代码。

就在你代码的 Animate 部分之前,添加一个 Sky 部分:

/**
 * Sky
 */

虽然按原样它还不能工作,但请将从示例中 initSky 函数内部的所有内容复制到你的代码中:

sky = new Sky();
sky.scale.setScalar( 450000 );
scene.add( sky );

sun = new THREE.Vector3();

/// GUI

const effectController = {
    turbidity: 10,
    rayleigh: 3,
    mieCoefficient: 0.005,
    mieDirectionalG: 0.7,
    elevation: 2,
    azimuth: 180,
    exposure: renderer.toneMappingExposure
};

function guiChanged() {

    const uniforms = sky.material.uniforms;
    uniforms[ 'turbidity' ].value = effectController.turbidity;
    uniforms[ 'rayleigh' ].value = effectController.rayleigh;
    uniforms[ 'mieCoefficient' ].value = effectController.mieCoefficient;
    uniforms[ 'mieDirectionalG' ].value = effectController.mieDirectionalG;

    const phi = THREE.MathUtils.degToRad( 90 - effectController.elevation );
    const theta = THREE.MathUtils.degToRad( effectController.azimuth );

    sun.setFromSphericalCoords( 1, phi, theta );

    uniforms[ 'sunPosition' ].value.copy( sun );

    renderer.toneMappingExposure = effectController.exposure;
    renderer.render( scene, camera );

}

const gui = new GUI();

gui.add( effectController, 'turbidity', 0.0, 20.0, 0.1 ).onChange( guiChanged );
gui.add( effectController, 'rayleigh', 0.0, 4, 0.001 ).onChange( guiChanged );
gui.add( effectController, 'mieCoefficient', 0.0, 0.1, 0.001 ).onChange( guiChanged );
gui.add( effectController, 'mieDirectionalG', 0.0, 1, 0.001 ).onChange( guiChanged );
gui.add( effectController, 'elevation', 0, 90, 0.1 ).onChange( guiChanged );
gui.add( effectController, 'azimuth', - 180, 180, 0.1 ).onChange( guiChanged );
gui.add( effectController, 'exposure', 0, 1, 0.0001 ).onChange( guiChanged );

guiChanged();

首先,我们需要从 Three.js 的 examples 中导入 Sky 类:

import { Sky } from 'three/addons/objects/Sky.js'

其次,我们需要在 sky 和 sun 前面添加 const:

const sky = new Sky();
// ...

const sun = new THREE.Vector3();

第三,删除 GUI 的实例化,因为我们已经有一个同名的了:

// const gui = new GUI();

现在天空应该可以工作了。

在调整它之前,让我们稍微重构一下代码。这取决于你和你的编码风格,但以下是我的做法,采用:

const sky = new Sky()
sky.scale.setScalar(450000)
scene.add(sky)

const sun = new THREE.Vector3()

const skyParameters = {
    turbidity: 10,
    rayleigh: 3,
    mieCoefficient: 0.005,
    mieDirectionalG: 0.7,
    elevation: 2,
    azimuth: 180,
    exposure: renderer.toneMappingExposure
}

const updateSky = () =>
{
    const uniforms = sky.material.uniforms
    uniforms['turbidity'].value = skyParameters.turbidity
    uniforms['rayleigh'].value = skyParameters.rayleigh
    uniforms['mieCoefficient'].value = skyParameters.mieCoefficient
    uniforms['mieDirectionalG'].value = skyParameters.mieDirectionalG

    const phi = THREE.MathUtils.degToRad(90 - skyParameters.elevation)
    const theta = THREE.MathUtils.degToRad(skyParameters.azimuth)

    sun.setFromSphericalCoords(1, phi, theta)

    uniforms['sunPosition'].value.copy(sun)

    renderer.toneMappingExposure = skyParameters.exposure
    renderer.render(scene, camera)
}

gui.add(skyParameters, 'turbidity', 0.0, 20.0, 0.1).onChange(updateSky)
gui.add(skyParameters, 'rayleigh', 0.0, 4, 0.001).onChange(updateSky)
gui.add(skyParameters, 'mieCoefficient', 0.0, 0.1, 0.001).onChange(updateSky)
gui.add(skyParameters, 'mieDirectionalG', 0.0, 1, 0.001).onChange(updateSky)
gui.add(skyParameters, 'elevation', 0, 90, 0.1).onChange(updateSky)
gui.add(skyParameters, 'azimuth', - 180, 180, 0.1).onChange(updateSky)
gui.add(skyParameters, 'exposure', 0, 1, 0.0001).onChange(updateSky)

updateSky()
lesson demo video preview

为了得到一个几乎像夜晚一样的暗色天空,你还应该将 'elevation' 的 min、max 和 step 设置为 -3、10 和 0.01:

gui.add(skyParameters, 'elevation', -3, 10, 0.01).onChange(updateSky)

玩得开心,找到一个好看的天空设置,然后将这些值添加到 skyParameters 对象中。

对我来说,会是:

const skyParameters = {
    turbidity: 10,
    rayleigh: 3,
    mieCoefficient: 0.005,
    mieDirectionalG: 0.95,
    elevation: -2.2,
    azimuth: 180,
    exposure: renderer.toneMappingExposure
}
lesson demo video preview

分离 shader chunks

我们将把 remap 函数移动到一个单独的文件中并 include 它。

在 src/shaders/ 中,创建一个 includes/ 文件夹。

在 src/shaders/includes/ 文件夹中,创建一个 remap.glsl 文件,并将 remap 函数放入其中:

float remap(float value, float originMin, float originMax, float destinationMin, float destinationMax)
{
    return destinationMin + (value - originMin) * (destinationMax - destinationMin) / (originMax - originMin);
}

回到 vertex.glsl,用一个 #include 代替该函数:

// ...

#include ../includes/remap.glsl

void main()
{
    // ...
}

确保一切已保存,项目应该会像之前一样工作。

这不是强制性的,但被认为是良好的实践,它能让该函数在其他 shader 中轻松复用。

进一步探索

我们的课程就到此结束了。如你所见,在 vertex shader 中为 particles 做动画很难,尤其是当动画由多个阶段组成时。

尽可能多地、尽可能清晰地分离这些阶段会有很大帮助。

一如既往,请随意进一步探索。以下是一些建议: